分类
联系方式
  1. 新浪微博
  2. E-mail

Maeiee Weekly No.25:用 Flutter 开发窗口管理器

Utopia 是一个开源窗口管理器(Window Manager)框架,特色是使用 Flutter 开发。基于它开发而成的桌面环境有 pangolin_desktop,它是 dahliaOS 系统的桌面环境。

最近我在折腾 Punk OS 这个 SideProject,它可以简单理解 OS in App,运行在 App 里的操作系统(用户空间)。基于 Flutter 技术,Punk OS 可以跨端运行。不论是在 WIndows 下,还是 Android 下,打开 Punk OS 应用,就仿佛进入了一个新的系统。

Punk OS 也需要一个桌面环境,于是本周我主要对 Utopia、pangolin_desktop 进行研究。本周主题是对这些项目的源码研究,对桌面管理器和如何用 Flutter 开发窗口管理器感兴趣的小伙伴,欢迎阅读。

utopia 对窗口的抽象

窗口是界面的容器,具体来说,包括几个部分:

  1. 窗口 ID:窗口的唯一标识
  2. 视图界面内容:即 App 的 UI
  3. 装饰器:标题栏(应用名称、最小化、最大化、关闭按钮、边框阴影)
  4. 窗口事件:缩放、拖拽、吸附
  5. 窗口特征:是否可聚焦、最小尺寸、最大尺寸、是否允许缩放、窗口形状、标题栏央视

围绕这几个部分,可以将架构抽象为状态层和展示层:

  • 状态层(LayoutState):窗口尺寸、位置、是否置顶、吸附状态、是否最小化、是否最大化
  • 视图层(WindowWrapper):包含 App 的 UI 内容(content),根据窗口特征执行绘制。

WindowFeature 窗口特征责任链

窗口自身包含一系列特性:是否可缩放,是否可聚焦(focusable),窗口的形状,窗口背景,以及工具栏、窗口边框的背景。

在前端组件化开发范式中,该如何优雅地支持这些功能呢?

在 utopia 中采取了责任链模式。首先定义了 WindowFeature 这个基类:

abstract class WindowFeature {
  const WindowFeature();

  Widget build(BuildContext context, Widget content);

  Set<WindowPropertyKey> get requiredProperties => {};
}

其中包含两个方法:

  • requiredProperties:用于传入参数
  • build:责任链方法,传入一个组件内容,并返回一个新的组件

窗口的每一个特性都是一个 WindowFeature,在构建 WindowEntry 时,传入一个 WindowFeature 的列表。在实际构建窗口时,通过递归执行责任链:

Widget _buildFeatures(BuildContext context, [int index = 0]) {
    if (index >= widget.features.length) {
      return SizedBox.expand(child: widget.content);
    }
    
    return widget.features[index]
        .build(context, _buildFeatures(context, index + 1));
}

这样便实现了:WindowFeatures 对于窗口内容(widget.content)的层层包装。

这种设计带来的好处是非常灵活。如果要添加新的窗口特性,实现一个新的 WindowFeature 即可。

同样,如果要更改已有的特性,比如更换标题栏样式,也只需要实现一个新的 WindowFeature,将老的替换掉即可。

窗口的尺寸与大小

在组件化的 UI 框架中,基于响应式开发,或称之为 MVVM 范式,实现窗口的大小与尺寸是非常方便的。

组件化的 UI 框架基于声明式开发理念,我们重点关注对于状态的描述。

在 Utopia 中,相关的实现是:

LayoutInfo

首先有一个抽象类 LayoutInfo,定义了与窗口布局相关的属性:

abstract class LayoutInfo {
  /// 窗口尺寸
  final Size size;

  /// 窗口位置
  final Offset position;

  /// 是否总是位于最顶
  final bool alwaysOnTop;

  /// 位于最顶也分为多种类型
  final AlwaysOnTopMode alwaysOnTopMode;

  /// 吸附模式
  final WindowDock dock;

  /// 是否最小化
  final bool minimized;

  /// 是否全屏
  final bool fullscreen;

  /// Const constructor to allow subclasses to be const.
  const LayoutInfo({……});

  /// This method is required in order to support overriding the layout info from
  /// the [WindowEntry.newInstance] method.
  LayoutInfo copyWith({……});

  /// Creates the associated [LayoutState] populating the fields that are needed
  /// for it to properly work. Should not be overridden or used directly, reserved
  /// for the library internal use.
  LayoutState createStateInternal([WindowEventHandler? eventHandler]) {
    final LayoutState state = createState();
    state._info = this;
    state._eventHandler = eventHandler;
    return state;
  }

  /// This method should return a newly created [LayoutState] subclass instance,
  /// similarly to how [StatefulWidget.createState] works.
  /// 抽象方法
  @protected
  @factory
  LayoutState createState();
}

该类有一个抽象方法 createState,返回的类型是 LayoutState。LayoutState 继承自 ChangeNotifier,通过 Provider 实现对实际窗口 Widget 的响应式控制。具体实现略过。

LayoutInfo 与 LayoutState 的绑定是在 createStateInternal 中,可以看到,LayoutInfo 把自己传入 LayoutState 实例中,同时也把 WindowEventHandler 传入其中。

与窗口控件绑定

ChangeNotifier 响应式的源头有了,咋哪里跟窗口建立起实际关联呢?

答案是在 WindowEntry 的 newInstance 方法中,该方法相当于创建一个实例化窗口出来。

LiveWindowEntry newInstance({
  Widget? content,  // 窗口内容
  WindowEventHandler? eventHandler, // 窗口事件管理
  LayoutInfo Function(LayoutInfo info)? overrideLayout,
  Map<WindowPropertyKey, Object?> overrideProperties = const {}, // 窗口属性
}) {
  ……

  final LayoutInfo info = overrideLayout?.call(layoutInfo) ?? layoutInfo;

  return LiveWindowEntry._(
    content: content ?? const SizedBox(),
    layoutState: info.createStateInternal(eventHandler),
    features: features,
    eventHandler: eventHandler,
    registry: WindowPropertyRegistry(initialData: completedProperties),
  );
}

把几个参与一起,最终在 LiveWindowEntry._ 中完成绑定:

LiveWindowEntry._({
  required this.content,
  required this.layoutState,
  required this.features,
  required this.registry,
  this.eventHandler,
}) : _view = MultiProvider(
        providers: [
          ChangeNotifierProvider.value(value: registry),
          ChangeNotifierProvider.value(value: layoutState),
          Provider.value(value: eventHandler),
        ],
        key: GlobalKey(),
        child: WindowWrapper(
          features: features,
          content: content,
          key: ValueKey(registry.info.id),
        ),
      );

这里看到了 layoutState 和 eventHandler、registry 都是 Provider,而窗口内容是 child,实现了响应式控制。

还记得 WindowWrapper 是啥吗?这是前一节中介绍的窗口特性装饰响应链!

窗口整体管理

前面介绍了单个窗口内部如何维护状态。一个桌面环境是由多窗口组成的,需要有一个总管,这项任务由 WindowHierarchy 和 WindowHierarchyController 负责。

WindowHierarchyController

存储所有窗口的状态,以及窗口管理器自身状态。具体代码如下:

class WindowHierarchyController with ChangeNotifier {
  late final _WindowHierarchyInternalState _state;
  // 所有窗口状态
  final List<LiveWindowEntry> _entries = [];
  // 焦点序列,以窗口 id 表征
  final List<String> _focusHierarchy = [];
  // 窗口管理器的不可用区域
  EdgeInsets _wmInsets = EdgeInsets.zero;

  /// 获取允许显示在任务栏上的窗口
  List<LiveWindowEntry> get entries => _entries
      .where((e) => e.registry.info.showOnTaskbar)
      .toList();

  /// 获取所有窗口的浅拷贝
  List<LiveWindowEntry> get rawEntries => List.unmodifiable(_entries);

  /// 获取焦点窗口的浅拷贝
  List<String> get focusHierarchy => List.unmodifiable(_focusHierarchy);

  // 关联 WindowHierarchy 的内部状态
  void _provideState(_WindowHierarchyInternalState state) {
    _state = state;
    _initialized = true;
  }

  /// 创建窗口实例
  void addWindowEntry(LiveWindowEntry entry) {
    _checkForInitialized();
    _entries.add(entry);
    _focusHierarchy.add(entry.registry.info.id);
    notifyListeners();
    _state._requestRebuild();
  }

  /// 删除窗口实例
  void removeWindowEntry(String id) {
    _checkForInitialized();
    final int entryIndex =
        _entries.indexWhere((element) => element.registry.info.id == id);
    _entries.removeAt(entryIndex).dispose();
    _focusHierarchy.remove(id);
    notifyListeners();
    _state._requestRebuild();
  }

  /// 请求焦点
  void requestEntryFocus(String id) {
    _checkForInitialized();
    final int idIndex = _focusHierarchy.indexWhere((element) => element == id);
    final String poppedId = _focusHierarchy.removeAt(idIndex);
    _focusHierarchy.add(poppedId);
    notifyListeners();
    _state._requestRebuild();
  }

  /// 获取窗口管理器的不可用区域
  EdgeInsets get wmInsets => _wmInsets;
  set wmInsets(EdgeInsets value) {
    _wmInsets = value;
    notifyListeners();
  }

  /// 获取生效尺寸
  Rect get wmBounds => _state._wmBounds;

  ///获取总展示尺寸
  Rect get displayBounds => _state._displayBounds;

  /// {@macro utopia.hierarchy.WindowEntryUtils.entriesByFocus}
  List<LiveWindowEntry> get entriesByFocus =>
      WindowEntryUtils.getEntriesByFocus(_entries, _focusHierarchy);

  /// {@macro utopia.hierarchy.WindowEntryUtils.sortedEntries}
  List<LiveWindowEntry> get sortedEntries =>
      WindowEntryUtils.getSortedEntries(_entries, _focusHierarchy);

  /// {@macro utopia.hierarchy.WindowEntryUtils.isFocused}
  bool isFocused(String id) => WindowEntryUtils.isFocused(_focusHierarchy, id);
}

在实际上层使用中,主要是通过该 Controller 实现窗口管理器的窗口创建与销毁操作。

WindowHierarchy

对 WindowHierarchyController 有了概念后,接下来来看 WindowHierarchy :

class WindowHierarchy extends StatelessWidget {
  // 上节提到的 Controller
  final WindowHierarchyController controller;

  /// 负责具体的布局操作
  final LayoutDelegate layoutDelegate;

  /// 是否使用父组件的尺寸作为 [controller.displayBounds]
  /// 如果为 false,总是使用 [MediaQueryData.size]
  final bool useParentSize;

  // 上层业务通常使用这个
  const WindowHierarchy({
    required this.controller,
    required this.layoutDelegate,
    this.useParentSize = false,
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    if (!useParentSize) return _buildChild(MediaQuery.of(context).size);

    return LayoutBuilder(
      builder: (context, constraints) => _buildChild(constraints.biggest),
    );
  }

  /// _WindowHierarchyInternal 需要关注
  Widget _buildChild(Size size) {
    return _WindowHierarchyInternal(
      controller: controller,
      layoutDelegate: layoutDelegate,
      size: size,
    );
  }
}

内部包含一个 _WindowHierarchyInternal:

/// 这是一个 StatefulWidget
class _WindowHierarchyInternal extends StatefulWidget {
  final WindowHierarchyController controller;
  final LayoutDelegate layoutDelegate;
  final Size size;

  const _WindowHierarchyInternal({
    required this.controller,
    required this.layoutDelegate,
    required this.size,
  });

  @override
  State<_WindowHierarchyInternal> createState() =>
      _WindowHierarchyInternalState();
}

class _WindowHierarchyInternalState extends State<_WindowHierarchyInternal> {
  @override
  void initState() {
    super.initState
    // 相互关联
    widget.controller._provideState(this);
  }

  // 供外界刷新用
  void _requestRebuild() {
    setState(() {});
  }

  Rect get _wmBounds => widget.controller.wmInsets.deflateRect(_displayBounds);
  Rect get _displayBounds => Offset.zero & widget.size;

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider.value(
      value: widget.controller, // 将 Controller 关联进去
      builder: (context, _) => _LayoutBuilder(
        delegate: widget.layoutDelegate,
        entries: widget.controller.rawEntries,
        focusHierarchy: widget.controller.focusHierarchy,
      ),
    );
  }
}

窗口缩放逻辑

窗口的缩放逻辑会放在哪里呢?放在 WindowFeature 的责任链里。

具体实现分为几个类:

  1. ResizeWindowFeature: WindowFeature 处理上层逻辑
  2. WindowResizeGestureDetector 响应具体拖动手势

下面分别来看。

ResizeWindowFeature

用户可以设置一系列属性:

// 最小尺寸
static const WindowPropertyKey<Size> minSize =
  WindowPropertyKey<Size>('feature.resize.minSize', Size.zero);

// 最大尺寸
static const WindowPropertyKey<Size> maxSize =
  WindowPropertyKey<Size>('feature.resize.maxSize', Size.infinite);

// 是否允许调整大小
static const WindowPropertyKey<bool> allowResize =
  WindowPropertyKey<bool>('feature.resize.allowResize', true);

build 方法:

@override
Widget build(BuildContext context, Widget content) {
  final WindowPropertyRegistry properties = WindowPropertyRegistry.of(context);
  final LayoutState layout = LayoutState.of(context);
  final WindowEventHandler? eventHandler = WindowEventHandler.maybeOf(context);

  // 创建手势组件 WindowResizeGestureDetector
  return WindowResizeGestureDetector(
    borderThickness: 8,
    // listeners 条件判断:如果允许更改大小、且没有吸附、且非全屏,则允许改变
    listeners: properties.resize.allowResize &&
            layout.dock == WindowDock.none &&
            !layout.fullscreen
        ? _getListeners(context) // 允许改变就是把监听传进去
        : null,
    // 改变开始时发送一个事件
    onStartResize: () => eventHandler?.onEvent(
      WindowResizeStartEvent(timestamp: DateTime.now()),
    ),
     // 改变结束时发送一个事件
    onEndResize: () => eventHandler?.onEvent(
      WindowResizeEndEvent(timestamp: DateTime.now()),
    ),
    // 窗口内容
    child: content,
  );
}

_getListeners 方法定义了一个方法字典:

Map<Alignment, GestureDragUpdateCallback> _getListeners(
  BuildContext context,
) {
  return {
    Alignment.topLeft: (details) =>
        _onPanUpdate(context, details, top: true, left: true),
    Alignment.topCenter: (details) =>
        _onPanUpdate(context, details, top: true),
    Alignment.topRight: (details) =>
        _onPanUpdate(context, details, top: true, right: true),
    Alignment.centerLeft: (details) =>
        _onPanUpdate(context, details, left: true),
    Alignment.centerRight: (details) =>
        _onPanUpdate(context, details, right: true),
    Alignment.bottomLeft: (details) =>
        _onPanUpdate(context, details, bottom: true, left: true),
    Alignment.bottomCenter: (details) =>
        _onPanUpdate(context, details, bottom: true),
    Alignment.bottomRight: (details) =>
        _onPanUpdate(context, details, bottom: true, right: true),
  };
}

根据不同的 key 映射到不同的回调方法,在每个回调方法中,调用 _onPanUpdate 进行统一处理。

在 _onPanUpdate 中,计算出窗口的新尺寸,改尺寸会更新到 LayoutState 中,从而通过 Provider 机制进行响应式更新。

WindowResizeGestureDetector

该组件掌管窗口缩放的手势响应。

在窗口的顶部,需要有三个缩放感受器:左上角的对角线缩放,上边的上下缩放,右上角的对角线缩放。对应代码为:

Widget buildFrame(BuildContext context) {
  return Column(
    children: [
      Row(
        children: [
          buildGestureDetector(
            borderThickness,
            borderThickness,
            listeners![Alignment.topLeft],
            SystemMouseCursors.resizeUpLeft,
          ),
          Expanded(
            child: buildGestureDetector(
              null,
              borderThickness,
              listeners![Alignment.topCenter],
              SystemMouseCursors.resizeUp,
            ),
          ),
          buildGestureDetector(
            borderThickness,
            borderThickness,
            listeners![Alignment.topRight],
            SystemMouseCursors.resizeUpRight,
          ),
        ],
      ),

buildGestureDetector 用于统一创建缩放感受器:

Widget buildGestureDetector(
  double? width,
  double? height,
  GestureDragUpdateCallback? onPanUpdate,
  SystemMouseCursor cursor,
) {
  return MouseRegion(
    cursor: cursor,
    child: SizedBox(
      width: width,
      height: height,
      child: GestureDetector(
        onPanStart: onStartResize != null ? (_) => onStartResize!() : null,
        onPanUpdate: onPanUpdate,
        onPanEnd: onEndResize != null ? (_) => onEndResize!() : null,
      ),
    ),
  );
}

其中的回调方法,都是由前面小节中传入进来的,即 listeners![Alignment.topLeft]。

窗口拖动逻辑

窗口拖动能力,实际上指的是窗口工具栏空白区域,支持拖动空白区域实现窗口移动。

具体代码位于 ToolbarWindowFeature 中,手势感受器:

GestureDetector(
  onPanStart: (details) {
    if (layout.dock != WindowDock.none) {
      layout.dock = WindowDock.none;
      layout.position = details.globalPosition +
          Offset(
            -layout.size.width / 2,
            -properties.toolbar.size / 2,
          );
    }
  },
  onPanUpdate: (details) {
    layout.position += details.delta;
  },
  onDoubleTap: () {
    if (layout.dock == WindowDock.maximized) {
      layout.dock = WindowDock.none;
    } else {
      layout.dock = WindowDock.maximized;
    }
  },
),

其中 layout 仍然是 LayoutState,改变后又能够响应式监听了。

窗口边缘吸附

这是一个非常实用的功能,许多窗口管理器都会附带。utopia 是如何实现这一功能的呢?

在 LayoutState 中有一个 WindowDock 枚举属性:

late WindowDock _dock = info.dock;

WindowDock 内部是一个枚举,声明了支持的吸附形式:

enum WindowDock {
  none,
  maximized,
  left,
  right,
  topLeft,
  topRight,
  bottomLeft,
  bottomRight,
}

可以看到,最大化也被定义为一种枚举形式。

在 utopia 中,只给出了对吸附的声明,但是没有给出实现。具体实现需要看 pangolin。

位于 PangolinLayoutDelegate 中。FreeformLayoutDelegate 是 utopia 提供的一种默认实现,而 PangolinLayoutDelegate 是另外实现了一套,并在新实现中包含了吸附逻辑。

除此之外,还要实现 tollbar 拖动到屏幕指定位置后,更新 dock 状态,实现闭环。

具体的实现逻辑是,还是在 tollbar 的移动手势监听中,判断窗口是否位于屏幕边缘,如果是,则更改 _dock 属性。在 LayoutDelegate 中,要监听 _dock 状态,优先将窗口摆放到固定吸附位置。

比如,判断是否位于屏幕边缘的算法是:

WindowDock _getDockForPosition(Rect bounds, Offset position) {
  final Rect topLeft = Rect.fromLTWH(
    bounds.left,
    bounds.top,
    PunkToolbar.dockEdgeSize,
    PunkToolbar.dockEdgeSize,
  );
  final Rect left = Rect.fromLTWH(
    bounds.left,
    bounds.top + PunkToolbar.dockEdgeSize,
    PunkToolbar.dockEdgeSize,
    bounds.height - PunkToolbar.dockEdgeSize * 2,
  );
  final Rect bottomLeft = Rect.fromLTWH(
    bounds.left,
    bounds.bottom - PunkToolbar.dockEdgeSize,
    PunkToolbar.dockEdgeSize,
    PunkToolbar.dockEdgeSize,
  );
  final Rect topRight = Rect.fromLTWH(
    bounds.right - PunkToolbar.dockEdgeSize,
    bounds.top,
    PunkToolbar.dockEdgeSize,
    PunkToolbar.dockEdgeSize,
  );
  final Rect right = Rect.fromLTWH(
    bounds.right - PunkToolbar.dockEdgeSize,
    bounds.top + PunkToolbar.dockEdgeSize,
    PunkToolbar.dockEdgeSize,
    bounds.height - PunkToolbar.dockEdgeSize * 2,
  );
  final Rect bottomRight = Rect.fromLTWH(
    bounds.right - PunkToolbar.dockEdgeSize,
    bounds.bottom - PunkToolbar.dockEdgeSize,
    PunkToolbar.dockEdgeSize,
    PunkToolbar.dockEdgeSize,
  );
  final Rect maximized = Rect.fromLTWH(
    bounds.left + PunkToolbar.dockEdgeSize,
    bounds.top,
    bounds.width - PunkToolbar.dockEdgeSize * 2,
    PunkToolbar.dockEdgeSize,
  );

  if (topLeft.contains(position)) return WindowDock.topLeft;
  if (left.contains(position)) return WindowDock.left;
  if (bottomLeft.contains(position)) return WindowDock.bottomLeft;

  if (topRight.contains(position)) return WindowDock.topRight;
  if (right.contains(position)) return WindowDock.right;
  if (bottomRight.contains(position)) return WindowDock.bottomRight;

  if (maximized.contains(position)) return WindowDock.maximized;

  return WindowDock.none;
}